
系列:30 天打造企業級 PLM|面向:全端|素材:
SseEmitterManager、前端 EventSource 封裝
「單子到我這關了怎麼沒人通知我?」沒有即時通知的簽核系統,流程卡在每個人的「忘記重整」上。更陰的是設定面:「我明明把權限開給他了!」管理員改了選單和權限,使用者那邊還是舊畫面,因為 Day 8 與 17 為效能加的前端 meta 快取沒有失效機制。今天講 SSE(Server-Sent Events)怎麼一次解掉業務通知與快取失效兩個問題。
在以前 Oracle Agile PLM 的時代,系統極度缺乏伺服器向瀏覽器主動推播的輕量機制:
Mini-PLM 採用標準的 Server-Sent Events (SSE),基於標準 HTTP 單向串流,防火牆穿透性極佳,後端一有狀態或 Meta 變更即時推播,前端靜默刷新。

這張圖刻意把「事件」與「資料」拆開:SSE 只告訴前端什麼變了,真正的內容仍由既有 REST API 按需取得。這樣做既保留原本的權限檢查與資料組裝路徑,也避免每種通知都複製一份資料查詢邏輯。
| 輪詢 | SSE | WebSocket | |
|---|---|---|---|
| 方向 | 拉 | 伺服器→客戶端 | 雙向 |
| 協定 | HTTP | HTTP(長連線) | 升級協定 |
| 斷線重連 | 天然 | 瀏覽器內建自動重連 | 自己寫 |
| 基礎設施相容 | 最好 | 好(就是 HTTP) | Proxy/LB 要配合 |
通知場景只需要單向,伺服器推、客戶端收。WebSocket 的雙向能力用不到,卻要付連線管理與基建配合的全額成本。SSE 走純 HTTP、瀏覽器原生 EventSource 自帶重連,單向推播選它剛剛好。
有個認證的取捨被迫發生:EventSource 不能自訂 header,JWT 沒地方放。解法是 SSE 連線改以 query parameter 帶 token(僅此端點),後端從 query 驗。代價是 token 可能進 access log,緩解是連線端點不記 query、token 本身短效。選了輕的協定,就要接受它的稜角。
SseEmitterManager 管理所有活連線,兩個工程細節直接寫在註解裡:
// online-count 廣播節流:連線建立/斷開只標記 dirty,
// 最短間隔內合併為一次廣播
/** online-count 廣播最短間隔(毫秒):期間內的連線增減合併為一次廣播 */
全公司幾百人上上下下線,每次增減都廣播在線人數,會把 SSE 通道灌爆。dirty 標記加最短間隔合併,推播系統的標配模式。另一個是心跳(sendHeartbeat(),定期推 alive):目的不是資料,是讓兩端知道連線還活著。proxy 與防火牆會安靜地掐死閒置連線,心跳讓死連線被及早發現清理,順帶 flush 待廣播的 online-count。
權限開了還是舊畫面的完整解法:管理員儲存選單、欄位定義或權限,後端發對應的 SSE 事件,前端訂閱者收到後刷新對應的 store。選單事件重抓選單樹、meta 事件失效 useMetaStore,各刷各的。
兩個修煉過的細節。通知範圍要準:早期版本改 A 群組的選單會通知全公司刷新,v2 分支的第一個 commit 就是修「menu update notification and refresh issues」。事件要帶範圍(哪些使用者受影響),亂槍打鳥的失效通知會讓快取形同虛設。事件與資料分離:SSE 只推「什麼變了」,不推資料本身,前端收到再走正常 API 拉新資料。權限檢查、資料組裝走既有路徑,推播通道不承擔資料正確性。
SSE 會斷(網路、休眠、proxy),關鍵頁面(簽核中的表單)配輪詢 fallback。這裡踩過一個永久卡死的坑:fallback 邏輯原本判斷「SSE 是否連著」決定要不要輪詢,但 EventSource 的連線狀態與「事件真的進得來」是兩回事,連線看似 OPEN、事件卻已斷流。
修正後的判準是資料新鮮度:記錄最後一次收到事件的 receivedAt,超過 5 秒沒消息就當 SSE 失效、輪詢接手;SSE 恢復(又收到事件)輪詢自動退場。配套是 unmount 時取消輪詢,離開頁面的 timer 不清,就是記憶體與請求的雙重洩漏。判斷活著要看輸出,不要看狀態旗標。

重點是把「連線存在」和「資料有更新」視為兩個不同狀態:EventSource 顯示 OPEN 只能代表連線尚未被瀏覽器判定關閉,不能證明事件仍然抵達。因此 fallback 應由 receivedAt 驅動,而非只看連線狀態。
SSE 推播與 AFTER_COMMIT trigger(Day 12)都在背景執行緒跑,呼叫到含權限檢查的 service 就炸 Unauthenticated:SecurityContext 是 ThreadLocal,不會跟過去。解法是背景任務手動架設 context,而且 finally 要還原 previous 而非 clear。執行緒池(尤其 CallerRunsPolicy)可能在呼叫者執行緒上直接跑,clear 會把呼叫者自己的登入狀態洗掉。ThreadLocal 加執行緒池,借東西要物歸原主。

選 SSE 是「單向就夠」的務實;工程重點在節流、心跳、範圍精準的失效事件;前端以新鮮度而非連線狀態判斷 fallback。第三週的功能面到此收齊,明日 Day 20 進入品質週:Playwright E2E 實戰。